Skip to content

fix(task-graph): cap bridgeSubGraphTaskEvents depth to prevent event amplification#601

Closed
sroussey wants to merge 3 commits into
mainfrom
claude/beautiful-mayer-j1anwi
Closed

fix(task-graph): cap bridgeSubGraphTaskEvents depth to prevent event amplification#601
sroussey wants to merge 3 commits into
mainfrom
claude/beautiful-mayer-j1anwi

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Summary

bridgeSubGraphTaskEvents installs one re-emit listener per event type per nesting level. A deeply nested compound task (e.g. a MapTask containing a GraphAsTask containing a MapTask …) bridges every level independently, so a single inner task_progress becomes N parent emits before reaching any wire subscriber. Combined with downstream consumers that keep a bounded event log (the sec/builder ExecutionEventLog is capped at 10k events), sustained nested fan-out can silently evict legitimate events.

This PR adds a defense-in-depth depth cap with a default of 16 levels. Once exceeded, the bridge installs no listeners and emits a single warn log noting the cap hit. The cap is overridable via a new maxDepth parameter; depth is auto-derived from a symbol-keyed marker on the parent graph so existing call sites (GraphAsTaskRunner, WhileTask, IteratorTaskRunner, FallbackTaskRunner, GraphAsTask streaming path) compile unchanged.

Approach

Depth tracking lives on the graph instance via a Symbol.for("@workglow/task-graph/SubGraphEventBridge.depth") keyed property — chosen over threading a counter through every caller because it keeps the diff to one file of fix code plus one re-export. The teardown restores the previous marker so an independently-rooted later bridge of the same subgraph instance does not inherit a stale counter.

Files

  • packages/task-graph/src/task-graph/SubGraphEventBridge.ts — depth cap + warn log
  • packages/task-graph/src/common.ts — re-export bridgeSubGraphTaskEvents so the test can drive it directly
  • packages/test/src/test/task-graph/TaskCompleteEvent.test.ts — three new tests

Backwards compatibility

Patch-bump compatible: both new parameters have defaults; every existing caller still compiles. No behavior change for graphs nesting fewer than 16 bridges deep.

Test plan

  • bun scripts/test.ts graph vitest — 74 files, 728 tests passing (including 3 new)
  • bun run build:types — 36 successful, 36 total
  • New tests cover: default cap kicks in at depth 16; warn log fires with { depth, maxDepth } fields; maxDepth override is honored

Generated with Claude Code

https://claude.ai/code/session_01V3e3m8cMRy5stFhDzGmZrF


Generated by Claude Code

…amplification

bridgeSubGraphTaskEvents installs one re-emit listener per event
type per nesting level. A deeply nested compound task (e.g., a
MapTask containing GraphAsTask containing MapTask) turns one inner
task_progress into N parent emits before reaching any wire
subscriber. Downstream consumers with a bounded event log (the
sec/builder ExecutionEventLog is capped at 10k events) can evict
legitimate events under sustained nested fan-out. Add a default
depth cap of 16 with a logged drop once exceeded, and let callers
override via the new maxDepth parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3e3m8cMRy5stFhDzGmZrF
@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 62.75% 25576 / 40755
🔵 Statements 62.59% 26465 / 42283
🔵 Functions 63.97% 4853 / 7586
🔵 Branches 51.42% 12519 / 24343
File CoverageNo changed files found.
Generated in workflow #2631 for commit ca833f1 by the Vitest Coverage Report Action

…arn per parent

Without the saturating stamp, when bridgeSubGraphTaskEvents short-circuits at
the depth cap, the subgraph is left without a BRIDGE_DEPTH marker — so the
next bridge level reads depth as undefined (0) and the cap leaks downstream.
Stamp the subgraph at maxDepth before the early return so every nested
bridge also short-circuits.

Also gate the warn through a module-level WeakSet keyed by parent graph: an
iterator with 1k iterations at over-cap was previously emitting 1k warns
per run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a defense-in-depth cap to bridgeSubGraphTaskEvents to prevent pathological event amplification in deeply nested compound-task graphs, and introduces tests to validate the new behavior.

Changes:

  • Add depth tracking + a default max depth (16) to drop bridging beyond the cap and emit a warning.
  • Re-export bridgeSubGraphTaskEvents from @workglow/task-graph’s common entry so tests can call it directly.
  • Add a new test suite covering default cap behavior, warning emission, maxDepth overrides, and warning de-duplication.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
packages/task-graph/src/task-graph/SubGraphEventBridge.ts Implements depth cap logic, stamping, and warn-once behavior to prevent event amplification.
packages/task-graph/src/common.ts Re-exports SubGraphEventBridge to make bridgeSubGraphTaskEvents available from the package entrypoint.
packages/test/src/test/task-graph/TaskCompleteEvent.test.ts Adds tests for depth-capping behavior, logging fields, override behavior, and warning de-duplication.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +63 to +77
if (depth >= maxDepth) {
// Stamp the subgraph at the cap so any nested bridge call (whose
// parentGraph is this subgraph) derives `depth >= maxDepth` and also
// short-circuits — otherwise the depth counter resets at the next level
// and the cap leaks downstream.
(subGraph as unknown as Record<symbol, number>)[BRIDGE_DEPTH] = maxDepth;
if (!warnedParents.has(parentGraph)) {
warnedParents.add(parentGraph);
getLogger().warn("bridgeSubGraphTaskEvents depth cap hit; dropping bridge", {
depth,
maxDepth,
});
}
return () => {};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. The depth >= maxDepth branch now saves previousDepth from subGraph before stamping and restores (or deletes) it in the returned teardown, identical to the pattern used in the normal path.

Comment on lines +448 to +466
// Emit a task_progress from the innermost graph and count how many bridges
// it traversed up to the outermost. Without the cap this would re-emit N
// times (once per parent); with the cap of 16 it stops after 16 hops.
const reachedDepths: number[] = [];
for (let i = 0; i < graphs.length; i++) {
const depth = i;
graphs[i].subscribe("task_progress", () => {
reachedDepths.push(depth);
});
}

graphs[N].emit("task_progress", "inner-id", 42, "msg");

// Innermost (depth N) saw the original emit; bridges propagate up at most
// 16 levels (the default cap), so the highest depth reached is N - 16.
const minReached = Math.min(...reachedDepths);
expect(minReached).toBeGreaterThanOrEqual(N - 16);
// The cap fired (depth reached 16), producing at least one warn.
expect(warnSpy).toHaveBeenCalled();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. The test now uses N = 17 and asserts the exact boundary: emitting from graphs[16] (depth 15, within cap) must reach graphs[0], while emitting from graphs[17] (depth 16, at cap) must not.

sroussey commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #606, which cherry-picks this PR's depth-cap change onto a fresh main alongside #604 and applies an extra-high-effort code review on top (including a fix for an over-cap depth-marker leak in SubGraphEventBridge found during that review). Closing in favor of #606.


Generated by Claude Code

@sroussey sroussey closed this Jul 2, 2026
sroussey added a commit that referenced this pull request Jul 2, 2026
…nop0e5

fix: bridge depth cap (#601) + janitor/Container/vector/StreamPump fixes (#604), reviewed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants